Coverage Report

Created: 2026-06-19 16:17

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\csshw\csshw\xtask\src\release.rs
Line
Count
Source
1
//! Release preparation and git tag creation.
2
//!
3
//! [`prepare_release`] bumps the version, optionally creates a maintenance
4
//! branch, updates `Cargo.toml` and `Cargo.lock`, generates the changelog,
5
//! commits, and pushes.
6
//!
7
//! [`create_release_tag`] validates the current state and creates an annotated
8
//! git tag that triggers the GitHub Actions release workflow.
9
10
use anyhow::{bail, Context, Result};
11
use semver::Version;
12
13
/// Type of version increment for a release.
14
#[derive(Debug, PartialEq)]
15
pub enum ReleaseType {
16
    /// Increment the major component (X.0.0).
17
    Major,
18
    /// Increment the minor component (0.X.0).
19
    Minor,
20
    /// Increment the patch component (0.0.X).
21
    Patch,
22
}
23
24
impl std::fmt::Display for ReleaseType {
25
18
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26
18
        match self {
27
0
            ReleaseType::Major => write!(f, "major"),
28
15
            ReleaseType::Minor => write!(f, "minor"),
29
3
            ReleaseType::Patch => write!(f, "patch"),
30
        }
31
18
    }
32
}
33
34
/// All side-effecting operations required by this module.
35
///
36
/// Each method maps to exactly one external operation, making every step
37
/// independently mockable in tests.
38
pub trait ReleaseSystem {
39
    /// Run `git status --porcelain` and return its stdout.
40
    ///
41
    /// # Errors
42
    ///
43
    /// Returns an error if the process fails.
44
    fn git_status_porcelain(&self) -> Result<String>;
45
46
    /// Return the current git branch name.
47
    ///
48
    /// # Errors
49
    ///
50
    /// Returns an error if the process fails.
51
    fn git_current_branch(&self) -> Result<String>;
52
53
    /// Create and switch to a new branch with `git checkout -b <name>`.
54
    ///
55
    /// # Arguments
56
    ///
57
    /// * `name` - Branch name to create.
58
    ///
59
    /// # Errors
60
    ///
61
    /// Returns an error if the process fails.
62
    fn git_checkout_new_branch(&self, name: &str) -> Result<()>;
63
64
    /// Switch to an existing branch with `git checkout <name>`.
65
    ///
66
    /// Relies on git's DWIM behaviour: when `<name>` exists only as
67
    /// `refs/remotes/origin/<name>`, a local tracking branch is created.
68
    /// The caller is responsible for fetching beforehand.
69
    ///
70
    /// # Arguments
71
    ///
72
    /// * `name` - Branch name to switch to.
73
    ///
74
    /// # Errors
75
    ///
76
    /// Returns an error if the process fails.
77
    fn git_checkout(&self, name: &str) -> Result<()>;
78
79
    /// Return `true` when `refs/heads/<name>` exists locally.
80
    ///
81
    /// # Arguments
82
    ///
83
    /// * `name` - Local branch name to look up.
84
    ///
85
    /// # Errors
86
    ///
87
    /// Returns an error if the process fails for a reason other than the ref
88
    /// not existing.
89
    fn git_branch_exists_local(&self, name: &str) -> Result<bool>;
90
91
    /// Return `true` when `refs/remotes/origin/<name>` exists locally.
92
    ///
93
    /// The remote ref is only present after a successful `git fetch`, so
94
    /// callers must fetch before relying on this answer to reflect the
95
    /// remote's actual state.
96
    ///
97
    /// # Arguments
98
    ///
99
    /// * `name` - Branch name to look up under `origin/`.
100
    ///
101
    /// # Errors
102
    ///
103
    /// Returns an error if the process fails for a reason other than the ref
104
    /// not existing.
105
    fn git_branch_exists_origin(&self, name: &str) -> Result<bool>;
106
107
    /// Stage the given files with `git add`.
108
    ///
109
    /// # Arguments
110
    ///
111
    /// * `files` - Paths to stage.
112
    ///
113
    /// # Errors
114
    ///
115
    /// Returns an error if the process fails.
116
    fn git_add(&self, files: &[String]) -> Result<()>;
117
118
    /// Commit staged changes with the given message.
119
    ///
120
    /// # Arguments
121
    ///
122
    /// * `message` - Commit message.
123
    /// * `no_verify` - When `true`, pass `--no-verify` to bypass git hooks.
124
    ///
125
    /// # Errors
126
    ///
127
    /// Returns an error if the process fails.
128
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()>;
129
130
    /// Run `git push` with the given extra arguments.
131
    ///
132
    /// # Arguments
133
    ///
134
    /// * `args` - Extra arguments appended to `git push`.
135
    ///
136
    /// # Errors
137
    ///
138
    /// Returns an error if the process fails.
139
    fn git_push(&self, args: &[String]) -> Result<()>;
140
141
    /// Open a GitHub pull request against `base` using `gh pr create --fill`.
142
    ///
143
    /// `--fill` derives title and body from the latest commit, so the caller
144
    /// must ensure that commit has the desired subject/body.
145
    ///
146
    /// # Arguments
147
    ///
148
    /// * `base` - Branch the PR targets.
149
    ///
150
    /// # Errors
151
    ///
152
    /// Returns an error if the process fails.
153
    fn gh_pr_create(&self, base: &str) -> Result<()>;
154
155
    /// Return `git tag -l <tag>` stdout for the given tag name.
156
    ///
157
    /// # Arguments
158
    ///
159
    /// * `tag` - Tag name to check.
160
    ///
161
    /// # Errors
162
    ///
163
    /// Returns an error if the process fails.
164
    fn git_tag_list(&self, tag: &str) -> Result<String>;
165
166
    /// Return the subject of the latest commit (`git log -1 --pretty=format:%s`).
167
    ///
168
    /// # Errors
169
    ///
170
    /// Returns an error if the process fails.
171
    fn git_log_latest_subject(&self) -> Result<String>;
172
173
    /// Run `git fetch`.
174
    ///
175
    /// # Errors
176
    ///
177
    /// Returns an error if the process fails (non-fatal; callers may continue).
178
    fn git_fetch(&self) -> Result<()>;
179
180
    /// Return the number of commits the local branch is behind `<branch>` on
181
    /// the remote.
182
    ///
183
    /// # Arguments
184
    ///
185
    /// * `branch` - Remote branch to compare against.
186
    ///
187
    /// # Errors
188
    ///
189
    /// Returns an error if the process fails.
190
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32>;
191
192
    /// Return the number of commits the local branch is ahead of `<branch>`
193
    /// on the remote.
194
    ///
195
    /// # Arguments
196
    ///
197
    /// * `branch` - Remote branch to compare against.
198
    ///
199
    /// # Errors
200
    ///
201
    /// Returns an error if the process fails.
202
    fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32>;
203
204
    /// Create an annotated git tag.
205
    ///
206
    /// # Arguments
207
    ///
208
    /// * `tag` - Tag name.
209
    /// * `message` - Annotation message.
210
    ///
211
    /// # Errors
212
    ///
213
    /// Returns an error if the process fails.
214
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()>;
215
216
    /// Push a tag to `origin`.
217
    ///
218
    /// # Arguments
219
    ///
220
    /// * `tag` - Tag name to push.
221
    ///
222
    /// # Errors
223
    ///
224
    /// Returns an error if the process fails.
225
    fn git_push_tag(&self, tag: &str) -> Result<()>;
226
227
    /// Read the contents of `Cargo.toml`.
228
    ///
229
    /// # Errors
230
    ///
231
    /// Returns an error if the file cannot be read.
232
    fn read_cargo_toml(&self) -> Result<String>;
233
234
    /// Write `content` to `Cargo.toml`.
235
    ///
236
    /// # Errors
237
    ///
238
    /// Returns an error if the write fails.
239
    fn write_cargo_toml(&self, content: &str) -> Result<()>;
240
241
    /// Run `cargo update --workspace` to refresh `Cargo.lock`.
242
    ///
243
    /// # Errors
244
    ///
245
    /// Returns an error if the process fails.
246
    fn cargo_update_workspace(&self) -> Result<()>;
247
248
    /// Generate the changelog for the current version.
249
    ///
250
    /// # Errors
251
    ///
252
    /// Returns an error if changelog generation fails.
253
    fn generate_changelog(&self) -> Result<()>;
254
255
    /// Display `message` and read a line of user input.
256
    ///
257
    /// # Arguments
258
    ///
259
    /// * `message` - Prompt text.
260
    ///
261
    /// # Returns
262
    ///
263
    /// The trimmed response string.
264
    ///
265
    /// # Errors
266
    ///
267
    /// Returns an error if stdin cannot be read.
268
    fn prompt_user(&self, message: &str) -> Result<String>;
269
}
270
271
/// Check whether `ref_name` exists via `git show-ref --verify`.
272
///
273
/// `git show-ref` is documented to exit 0 when the ref exists, 1 when it does
274
/// not, and other non-zero codes for actual failures (bad arguments, broken
275
/// repo, etc.). Mapping every non-zero exit to "missing" would silently
276
/// swallow real errors, so the latter must surface as an `Err`.
277
#[cfg_attr(coverage_nightly, coverage(off))]
278
fn show_ref_exists(ref_name: &str) -> Result<bool> {
279
    let output = std::process::Command::new("git")
280
        .args(["show-ref", "--verify", "--quiet", ref_name])
281
        .output()
282
        .context("failed to run `git show-ref`")?;
283
    match output.status.code() {
284
        Some(0) => Ok(true),
285
        Some(1) => Ok(false),
286
        _ => bail!(
287
            "`git show-ref --verify {ref_name}` failed with status {}: {}",
288
            output.status,
289
            String::from_utf8_lossy(&output.stderr).trim(),
290
        ),
291
    }
292
}
293
294
/// Run `git rev-list --count <range>` and return the parsed commit count.
295
///
296
/// A non-zero exit (e.g. unknown ref) or unparseable stdout must surface as an
297
/// `Err` - returning `0` would silently mask "stale ref" or "git failed" as
298
/// "branch is up to date".
299
#[cfg_attr(coverage_nightly, coverage(off))]
300
fn rev_list_count(range: &str) -> Result<u32> {
301
    let output = std::process::Command::new("git")
302
        .args(["rev-list", "--count", range])
303
        .output()
304
        .context("failed to run `git rev-list`")?;
305
    if !output.status.success() {
306
        bail!(
307
            "`git rev-list --count {range}` failed with status {}: {}",
308
            output.status,
309
            String::from_utf8_lossy(&output.stderr).trim(),
310
        );
311
    }
312
    let stdout = String::from_utf8_lossy(&output.stdout);
313
    stdout.trim().parse::<u32>().with_context(|| {
314
        format!("failed to parse `git rev-list --count {range}` stdout: {stdout:?}")
315
    })
316
}
317
318
/// Production implementation of [`ReleaseSystem`].
319
pub struct RealSystem;
320
321
#[cfg_attr(coverage_nightly, coverage(off))]
322
impl ReleaseSystem for RealSystem {
323
    fn git_status_porcelain(&self) -> Result<String> {
324
        let output = std::process::Command::new("git")
325
            .args(["status", "--porcelain"])
326
            .output()
327
            .context("failed to run `git status --porcelain`")?;
328
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
329
    }
330
331
    fn git_current_branch(&self) -> Result<String> {
332
        let output = std::process::Command::new("git")
333
            .args(["branch", "--show-current"])
334
            .output()
335
            .context("failed to run `git branch --show-current`")?;
336
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
337
    }
338
339
    fn git_checkout_new_branch(&self, name: &str) -> Result<()> {
340
        let status = std::process::Command::new("git")
341
            .args(["checkout", "-b", name])
342
            .status()
343
            .context("failed to run `git checkout -b`")?;
344
        if !status.success() {
345
            bail!("`git checkout -b {name}` failed with status {status}");
346
        }
347
        Ok(())
348
    }
349
350
    fn git_checkout(&self, name: &str) -> Result<()> {
351
        let status = std::process::Command::new("git")
352
            .args(["checkout", name])
353
            .status()
354
            .context("failed to run `git checkout`")?;
355
        if !status.success() {
356
            bail!("`git checkout {name}` failed with status {status}");
357
        }
358
        Ok(())
359
    }
360
361
    fn git_branch_exists_local(&self, name: &str) -> Result<bool> {
362
        show_ref_exists(&format!("refs/heads/{name}"))
363
    }
364
365
    fn git_branch_exists_origin(&self, name: &str) -> Result<bool> {
366
        show_ref_exists(&format!("refs/remotes/origin/{name}"))
367
    }
368
369
    fn git_add(&self, files: &[String]) -> Result<()> {
370
        let status = std::process::Command::new("git")
371
            .arg("add")
372
            .args(files)
373
            .status()
374
            .context("failed to run `git add`")?;
375
        if !status.success() {
376
            bail!("`git add` failed with status {status}");
377
        }
378
        Ok(())
379
    }
380
381
    fn git_commit(&self, message: &str, no_verify: bool) -> Result<()> {
382
        let mut cmd = std::process::Command::new("git");
383
        cmd.args(["commit", "-m", message]);
384
        if no_verify {
385
            cmd.arg("--no-verify");
386
        }
387
        let status = cmd.status().context("failed to run `git commit`")?;
388
        if !status.success() {
389
            bail!("`git commit` failed with status {status}");
390
        }
391
        Ok(())
392
    }
393
394
    fn git_push(&self, args: &[String]) -> Result<()> {
395
        let status = std::process::Command::new("git")
396
            .arg("push")
397
            .args(args)
398
            .status()
399
            .context("failed to run `git push`")?;
400
        if !status.success() {
401
            bail!("`git push` failed with status {status}");
402
        }
403
        Ok(())
404
    }
405
406
    fn gh_pr_create(&self, base: &str) -> Result<()> {
407
        let status = std::process::Command::new("gh")
408
            .args(["pr", "create", "--base", base, "--fill"])
409
            .status()
410
            .context("failed to run `gh pr create`")?;
411
        if !status.success() {
412
            bail!("`gh pr create --base {base}` failed with status {status}");
413
        }
414
        Ok(())
415
    }
416
417
    fn git_tag_list(&self, tag: &str) -> Result<String> {
418
        let output = std::process::Command::new("git")
419
            .args(["tag", "-l", tag])
420
            .output()
421
            .context("failed to run `git tag -l`")?;
422
        Ok(String::from_utf8_lossy(&output.stdout).into_owned())
423
    }
424
425
    fn git_log_latest_subject(&self) -> Result<String> {
426
        let output = std::process::Command::new("git")
427
            .args(["log", "-1", "--pretty=format:%s"])
428
            .output()
429
            .context("failed to run `git log`")?;
430
        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
431
    }
432
433
    fn git_fetch(&self) -> Result<()> {
434
        let status = std::process::Command::new("git")
435
            .arg("fetch")
436
            .status()
437
            .context("failed to run `git fetch`")?;
438
        if !status.success() {
439
            bail!("`git fetch` failed with status {status}");
440
        }
441
        Ok(())
442
    }
443
444
    fn git_rev_list_count_behind(&self, branch: &str) -> Result<u32> {
445
        rev_list_count(&format!("HEAD..origin/{branch}"))
446
    }
447
448
    fn git_rev_list_count_ahead(&self, branch: &str) -> Result<u32> {
449
        rev_list_count(&format!("origin/{branch}..HEAD"))
450
    }
451
452
    fn git_create_annotated_tag(&self, tag: &str, message: &str) -> Result<()> {
453
        let status = std::process::Command::new("git")
454
            .args(["tag", "-a", tag, "-m", message])
455
            .status()
456
            .context("failed to run `git tag -a`")?;
457
        if !status.success() {
458
            bail!("`git tag -a {tag}` failed with status {status}");
459
        }
460
        Ok(())
461
    }
462
463
    fn git_push_tag(&self, tag: &str) -> Result<()> {
464
        let status = std::process::Command::new("git")
465
            .args(["push", "origin", tag])
466
            .status()
467
            .context("failed to run `git push origin <tag>`")?;
468
        if !status.success() {
469
            bail!("`git push origin {tag}` failed with status {status}");
470
        }
471
        Ok(())
472
    }
473
474
    fn read_cargo_toml(&self) -> Result<String> {
475
        std::fs::read_to_string("Cargo.toml").context("failed to read Cargo.toml")
476
    }
477
478
    fn write_cargo_toml(&self, content: &str) -> Result<()> {
479
        std::fs::write("Cargo.toml", content).context("failed to write Cargo.toml")
480
    }
481
482
    fn cargo_update_workspace(&self) -> Result<()> {
483
        let status = std::process::Command::new("cargo")
484
            .args(["update", "--workspace"])
485
            .status()
486
            .context("failed to run `cargo update --workspace`")?;
487
        if !status.success() {
488
            bail!("`cargo update --workspace` failed with status {status}");
489
        }
490
        Ok(())
491
    }
492
493
    fn generate_changelog(&self) -> Result<()> {
494
        crate::changelog::generate_changelog(&crate::changelog::RealSystem)
495
    }
496
497
    fn prompt_user(&self, message: &str) -> Result<String> {
498
        use std::io::Write;
499
        print!("{message}");
500
        std::io::stdout()
501
            .flush()
502
            .context("failed to flush stdout")?;
503
        let mut input = String::new();
504
        std::io::stdin()
505
            .read_line(&mut input)
506
            .context("failed to read user input")?;
507
        Ok(input.trim().to_owned())
508
    }
509
}
510
511
/// Determine the suggested next version and release type from the current branch.
512
///
513
/// `main` -> minor bump; `*-maintenance` -> patch bump.
514
///
515
/// # Arguments
516
///
517
/// * `current` - Current version from `Cargo.toml`.
518
/// * `branch` - Current git branch name.
519
///
520
/// # Returns
521
///
522
/// `(ReleaseType, next_version)`.
523
///
524
/// # Errors
525
///
526
/// Returns an error when `branch` is neither `main` nor ends with
527
/// `-maintenance`.
528
13
pub fn suggest_next_version(current: &Version, branch: &str) -> Result<(ReleaseType, Version)> {
529
13
    if branch == "main" {
530
9
        let mut next = current.clone();
531
9
        next.minor += 1;
532
9
        next.patch = 0;
533
9
        Ok((ReleaseType::Minor, next))
534
4
    } else if branch.ends_with("-maintenance") {
535
2
        let mut next = current.clone();
536
2
        next.patch += 1;
537
2
        Ok((ReleaseType::Patch, next))
538
    } else {
539
2
        bail!(
540
            "must be on 'main' or a '*-maintenance' branch to prepare a release \
541
             (current branch: {branch})"
542
        )
543
    }
544
13
}
545
546
/// Determine the release type by comparing two versions.
547
///
548
/// # Arguments
549
///
550
/// * `current` - The version before the release.
551
/// * `next` - The version after the release.
552
///
553
/// # Returns
554
///
555
/// The most significant component that changed.
556
4
pub fn determine_release_type(current: &Version, next: &Version) -> ReleaseType {
557
4
    if next.major > current.major {
558
1
        ReleaseType::Major
559
3
    } else if next.minor > current.minor {
560
1
        ReleaseType::Minor
561
    } else {
562
2
        ReleaseType::Patch
563
    }
564
4
}
565
566
/// Rewrite the `[package].version` field in a `Cargo.toml` string.
567
///
568
/// Uses `toml_edit` to preserve all existing formatting.
569
///
570
/// # Arguments
571
///
572
/// * `cargo_toml_content` - Raw TOML text of `Cargo.toml`.
573
/// * `new_version` - Version string to set.
574
///
575
/// # Returns
576
///
577
/// Updated TOML text.
578
///
579
/// # Errors
580
///
581
/// Returns an error if `cargo_toml_content` cannot be parsed as TOML.
582
8
pub fn set_cargo_toml_version(cargo_toml_content: &str, new_version: &str) -> Result<String> {
583
8
    let mut doc: toml_edit::DocumentMut = cargo_toml_content
584
8
        .parse()
585
8
        .context("failed to parse Cargo.toml")
?0
;
586
8
    doc["package"]["version"] = toml_edit::value(new_version);
587
8
    Ok(doc.to_string())
588
8
}
589
590
/// Ensure the maintenance branch exists and is checked out before any release
591
/// prepared from `main` (major/minor create + push the maintenance branch and
592
/// then branch off `release-X.Y.Z`; a custom patch version typed on `main`
593
/// switches to an existing maintenance branch and pushes the version bump
594
/// directly).
595
///
596
/// Fetches once, then handles the four
597
/// (local exists, origin exists) combinations:
598
///
599
/// - `(false, false)`: create the branch from the current HEAD (`main`) and
600
///   push it to `origin`.
601
/// - `(true, false)`: switch to the existing local branch and push it.
602
/// - `(false, true)`: switch to the branch - git's DWIM creates a local
603
///   tracking branch from `origin/<name>`.
604
/// - `(true, true)`: switch to the local branch and verify it is neither
605
///   behind nor ahead of `origin`.
606
///
607
/// A failed `git fetch` is fatal here: every subsequent decision depends on
608
/// `refs/remotes/origin/*` reflecting the remote's actual state, and a stale
609
/// view can cause the wrong branch (create / push / checkout / fail-behind)
610
/// to be taken.
611
///
612
/// # Arguments
613
///
614
/// * `system` - Injected I/O provider.
615
/// * `maintenance_branch` - Name of the maintenance branch to ready.
616
///
617
/// # Errors
618
///
619
/// Returns an error if any git step fails or the local branch is behind or
620
/// ahead of origin.
621
8
fn ensure_maintenance_branch_ready<S: ReleaseSystem>(
622
8
    system: &S,
623
8
    maintenance_branch: &str,
624
8
) -> Result<()> {
625
8
    println!("INFO - Fetching origin to check maintenance branch state");
626
8
    system
627
8
        .git_fetch()
628
8
        .context("failed to fetch from origin - cannot determine maintenance branch state")
?1
;
629
630
7
    let local_exists = system.git_branch_exists_local(maintenance_branch)
?0
;
631
7
    let origin_exists = system.git_branch_exists_origin(maintenance_branch)
?0
;
632
633
7
    match (local_exists, origin_exists) {
634
        (false, false) => {
635
1
            println!(
636
                "INFO - Maintenance branch {maintenance_branch} does not exist; \
637
                 creating from current HEAD and pushing to origin"
638
            );
639
1
            system.git_checkout_new_branch(maintenance_branch)
?0
;
640
1
            system.git_push(&[
641
1
                "-u".to_owned(),
642
1
                "origin".to_owned(),
643
1
                maintenance_branch.to_owned(),
644
1
            ])
?0
;
645
        }
646
        (true, false) => {
647
1
            println!(
648
                "INFO - Maintenance branch {maintenance_branch} exists locally only; \
649
                 switching to it and pushing to origin"
650
            );
651
1
            system.git_checkout(maintenance_branch)
?0
;
652
1
            system.git_push(&[
653
1
                "-u".to_owned(),
654
1
                "origin".to_owned(),
655
1
                maintenance_branch.to_owned(),
656
1
            ])
?0
;
657
        }
658
        (false, true) => {
659
1
            println!(
660
                "INFO - Maintenance branch {maintenance_branch} exists on origin only; \
661
                 creating a local tracking branch"
662
            );
663
1
            system.git_checkout(maintenance_branch)
?0
;
664
        }
665
        (true, true) => {
666
4
            println!(
667
                "INFO - Maintenance branch {maintenance_branch} exists locally and on \
668
                 origin; switching to local branch"
669
            );
670
4
            system.git_checkout(maintenance_branch)
?0
;
671
4
            let behind = system.git_rev_list_count_behind(maintenance_branch)
?0
;
672
4
            if behind > 0 {
673
1
                bail!(
674
                    "local maintenance branch {maintenance_branch} is {behind} commit(s) \
675
                     behind origin - run `git pull` first"
676
                );
677
3
            }
678
            // Unpushed local commits would otherwise leak into the release PR
679
            // when we branch off `release-X.Y.Z` from here.
680
3
            let ahead = system.git_rev_list_count_ahead(maintenance_branch)
?0
;
681
3
            if ahead > 0 {
682
1
                bail!(
683
                    "local maintenance branch {maintenance_branch} is {ahead} commit(s) \
684
                     ahead of origin - push it before preparing a release"
685
                );
686
2
            }
687
        }
688
    }
689
5
    Ok(())
690
8
}
691
692
/// Prepare a new release.
693
///
694
/// Full workflow:
695
/// 1. Verify working tree is clean.
696
/// 2. Detect branch and suggest release type / next version.
697
/// 3. Prompt user (accepts custom version input).
698
/// 4. When releasing from `main`: ensure the target maintenance branch is
699
///    ready (see [`ensure_maintenance_branch_ready`]). For a major/minor
700
///    release this creates the branch when missing; for a patch release
701
///    entered as a custom version it switches to the existing branch.
702
///    Then, for a major/minor release, branch off a `release-X.Y.Z` branch
703
///    for the version bump. For a patch release on a maintenance branch
704
///    (or switched to one above), stay on that branch.
705
/// 5. Update `Cargo.toml` version.
706
/// 6. Run `cargo update --workspace`.
707
/// 7. Generate changelog.
708
/// 8. Commit the version bump.
709
/// 9. For a major/minor release from `main`: push the `release-X.Y.Z`
710
///    branch and open a GH PR against the maintenance branch. For a patch
711
///    release: push directly to the maintenance branch.
712
///
713
/// # Arguments
714
///
715
/// * `system` - Injected I/O provider.
716
///
717
/// # Errors
718
///
719
/// Returns an error if any step fails.
720
11
pub fn prepare_release<S: ReleaseSystem>(system: &S) -> Result<()> {
721
11
    let status = system.git_status_porcelain()
?0
;
722
11
    if !status.trim().is_empty() {
723
1
        bail!("git working directory is not clean - commit or stash changes first:\n{status}");
724
10
    }
725
726
10
    let current_branch = system.git_current_branch()
?0
;
727
10
    let cargo_toml = system.read_cargo_toml()
?0
;
728
10
    let current_version: Version = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
729
10
        .parse()
730
10
        .context("failed to parse current version as semver")
?0
;
731
732
10
    println!("INFO - Current branch: {current_branch}");
733
10
    println!("INFO - Current version: {current_version}");
734
735
9
    let (suggested_type, suggested_version) =
736
10
        suggest_next_version(&current_version, &current_branch)
?1
;
737
738
9
    let prompt = format!(
739
        "Preparing {suggested_type} release: {current_version} -> {suggested_version}. Continue? [Y/n]: "
740
    );
741
9
    let answer = system.prompt_user(&prompt)
?0
;
742
743
9
    let (next_version, actual_type) =
744
9
        if answer.eq_ignore_ascii_case("n") || 
answer8
.eq_ignore_ascii_case("no") {
745
1
            let custom_str = system.prompt_user(&format!(
746
1
                "Enter custom version (current: {current_version}): "
747
1
            ))
?0
;
748
1
            if custom_str.is_empty() {
749
0
                bail!("version cannot be empty");
750
1
            }
751
1
            let custom: Version = custom_str
752
1
                .parse()
753
1
                .context("invalid version format - use semantic versioning (e.g. 1.2.3)")
?0
;
754
1
            let release_type = determine_release_type(&current_version, &custom);
755
1
            (custom, release_type)
756
8
        } else if answer.is_empty()
757
8
            || answer.eq_ignore_ascii_case("y")
758
0
            || answer.eq_ignore_ascii_case("yes")
759
        {
760
8
            (suggested_version, suggested_type)
761
        } else {
762
0
            bail!("invalid input - please enter Y or n");
763
        };
764
765
9
    let releases_from_main = current_branch == "main";
766
9
    let opens_pr =
767
9
        releases_from_main && 
matches!1
(
actual_type8
, ReleaseType::Major | ReleaseType::Minor);
768
9
    let maintenance_branch = if releases_from_main {
769
8
        format!("{}.{}-maintenance", next_version.major, next_version.minor)
770
    } else {
771
1
        current_branch.clone()
772
    };
773
9
    let pr_branch = opens_pr.then(|| 
format!7
("release-{next_version}"));
774
775
9
    println!("INFO - Preparing {actual_type} release: {current_version} -> {next_version}");
776
9
    println!("INFO - Maintenance branch: {maintenance_branch}");
777
778
9
    if releases_from_main {
779
8
        ensure_maintenance_branch_ready(system, &maintenance_branch)
?3
;
780
1
    }
781
782
6
    if let Some(
pr_branch_name4
) = pr_branch.as_deref() {
783
4
        println!("INFO - Creating release branch: {pr_branch_name}");
784
4
        system.git_checkout_new_branch(pr_branch_name)
?0
;
785
2
    }
786
787
6
    println!("INFO - Updating Cargo.toml version to {next_version}");
788
6
    let updated_cargo = set_cargo_toml_version(&cargo_toml, &next_version.to_string())
?0
;
789
6
    system.write_cargo_toml(&updated_cargo)
?0
;
790
791
6
    println!("INFO - Updating Cargo.lock");
792
6
    system.cargo_update_workspace()
?0
;
793
794
6
    println!("INFO - Generating changelog");
795
6
    system.generate_changelog()
?0
;
796
797
6
    let commit_message = format!("Version {next_version}");
798
6
    println!("INFO - Committing: {commit_message}");
799
6
    system.git_add(&[
800
6
        "Cargo.toml".to_owned(),
801
6
        "Cargo.lock".to_owned(),
802
6
        "CHANGELOG.md".to_owned(),
803
6
        "changelogging.toml".to_owned(),
804
6
    ])
?0
;
805
    // Skip pre-commit hooks: the project's hook runs `cargo build --workspace
806
    // --all-targets`, which would try to replace the running xtask.exe and
807
    // fail on Windows with an access-denied error.
808
6
    system.git_commit(&commit_message, true)
?0
;
809
810
6
    if let Some(
pr_branch_name4
) = pr_branch.as_deref() {
811
4
        println!("INFO - Pushing release branch: {pr_branch_name}");
812
4
        system.git_push(&[
813
4
            "-u".to_owned(),
814
4
            "origin".to_owned(),
815
4
            pr_branch_name.to_owned(),
816
4
        ])
?0
;
817
818
4
        println!("INFO - Opening PR against {maintenance_branch}");
819
4
        system.gh_pr_create(&maintenance_branch)
?0
;
820
821
4
        println!(
822
            "INFO - Release {next_version} prepared on branch {pr_branch_name} \
823
             with PR against {maintenance_branch}"
824
        );
825
4
        println!(
826
            "INFO - After the PR is merged, switch to {maintenance_branch}, \
827
             pull, and run `cargo xtask create-release-tag` to tag the release"
828
        );
829
    } else {
830
2
        println!("INFO - Pushing to remote");
831
2
        system.git_push(&[])
?0
;
832
833
2
        println!("INFO - Release {next_version} prepared on branch {maintenance_branch}");
834
2
        println!("INFO - Run `cargo xtask create-release-tag` to tag the release");
835
    }
836
6
    Ok(())
837
11
}
838
839
/// Create and push an annotated git tag for the current release version.
840
///
841
/// Full workflow:
842
/// 1. Verify on a maintenance branch.
843
/// 2. Read version from `Cargo.toml`.
844
/// 3. Check the tag does not already exist.
845
/// 4. Verify the latest commit message is `"Version X.Y.Z"`.
846
/// 5. Fetch from remote and check not behind.
847
/// 6. Prompt user for confirmation.
848
/// 7. Create annotated tag and push.
849
///
850
/// # Arguments
851
///
852
/// * `system` - Injected I/O provider.
853
///
854
/// # Errors
855
///
856
/// Returns an error if any validation step fails.
857
6
pub fn create_release_tag<S: ReleaseSystem>(system: &S) -> Result<()> {
858
6
    let current_branch = system.git_current_branch()
?0
;
859
6
    if !current_branch.ends_with("-maintenance") {
860
1
        bail!(
861
            "must be on a maintenance branch to create a release tag \
862
             (current branch: {current_branch}) - run `cargo xtask prepare-release` first"
863
        );
864
5
    }
865
866
5
    let cargo_toml = system.read_cargo_toml()
?0
;
867
5
    let version_str = crate::changelog::extract_version_from_cargo_toml(&cargo_toml)
?0
;
868
5
    let version: Version = version_str
869
5
        .parse()
870
5
        .context("failed to parse version as semver")
?0
;
871
872
5
    println!("INFO - Current branch: {current_branch}");
873
5
    println!("INFO - Version to tag: {version}");
874
875
5
    let existing_tag = system.git_tag_list(&version.to_string())
?0
;
876
5
    if !existing_tag.trim().is_empty() {
877
1
        bail!("tag {version} already exists");
878
4
    }
879
880
4
    let commit_msg = system.git_log_latest_subject()
?0
;
881
4
    let expected_msg = format!("Version {version}");
882
4
    if commit_msg != expected_msg {
883
1
        bail!(
884
            "latest commit message does not match expected version commit\n\
885
             expected: {expected_msg}\n\
886
             actual:   {commit_msg}\n\
887
             run `cargo xtask prepare-release` first"
888
        );
889
3
    }
890
891
3
    println!("INFO - Fetching latest changes from remote");
892
3
    if let Err(
e0
) = system.git_fetch() {
893
0
        eprintln!("WARN - Failed to fetch from remote, continuing anyway: {e}");
894
3
    }
895
896
3
    let behind = system.git_rev_list_count_behind(&current_branch)
?0
;
897
3
    if behind > 0 {
898
1
        bail!("local branch is {behind} commit(s) behind remote - run `git pull` first");
899
2
    }
900
901
2
    let answer = system.prompt_user(&format!(
902
2
        "About to create and push tag '{version}'. Continue? [Y/n]: "
903
2
    ))
?0
;
904
2
    if answer.eq_ignore_ascii_case("n") || 
answer1
.eq_ignore_ascii_case("no") {
905
1
        println!("INFO - Tag creation cancelled");
906
1
        return Ok(());
907
1
    }
908
909
1
    let tag_message = format!("Version {version}");
910
1
    println!("INFO - Creating annotated tag: {version}");
911
1
    system.git_create_annotated_tag(&version.to_string(), &tag_message)
?0
;
912
913
1
    println!("INFO - Pushing tag to remote");
914
1
    system.git_push_tag(&version.to_string())
?0
;
915
916
1
    println!("INFO - Tag '{version}' created and pushed");
917
1
    println!("INFO - Check: https://github.com/whme/csshw/actions/workflows/release.yml");
918
1
    Ok(())
919
6
}
920
921
#[cfg(test)]
922
#[path = "tests/test_release.rs"]
923
mod tests;